package calao.util.xml;

import java.io.IOException;
import java.io.PrintStream;
import java.io.Reader;
import java.io.PushbackReader;

public abstract class XmlParser 
<E extends Enum, P extends Enum> 
{
	
	final PushbackReader reader;
	final Class<E> elementNameEnum;
	final Class<P> propertyNameEnum;
	NameMap<E> elementNameMap;
	NameMap<P> propertyNameMap;
	XmlElement<E,P> current;
	boolean traced = false;
	int lastIdLine, lastIdColumn;
    int line = 1;
    int column = 0;
    int traceDepth = 0;
    int lastColumn = -1;
    StringBuffer textBuffer = new StringBuffer();
    StringBuffer nameBuffer = new StringBuffer();
    PrintStream traceStream = null;
	
	protected XmlParser(Reader reader, Class<E> elementEnum, Class<P> propertyEnum)
	{
		this.reader = new PushbackReader(reader, 3);
		this.elementNameEnum = elementEnum;
		this.propertyNameEnum = propertyEnum;
	}
	
	protected abstract void handle(XmlElement<E,P> element)
	throws IOException, XmlParseException;	
	
	private XmlElement<E,P> readElement(TagChecker<E> tagChecker)
	throws XmlParseException, IOException
	{
		if (current!=null && current.empty) return null;
        if (traced) traceStart("parsing node...");
        skipSpecialTags(null);
        expect('<');
        if (peek()=='/')
        {
            pushback('<');
            if (traced) traceEnd("no more node");
            return null;
        }
        String startTagName = readName();
        E startTag = elementNameMap.getEnum(startTagName);
		if (startTag==null || !tagChecker.isValid(startTag)) {
			throw new XmlParseException(this, startTag+": invalid tag");
		}
		XmlElement<E,P> element = current = newElement(startTag, current);
        readProperties(element);
        element.empty = next('/');
        expect('>');
		handle(element);
		if (!element.empty) {
			skipSpecialTags(null);
			expect('<', '/');
			String endTagName = readName();
			if (!elementNameMap.getName(startTag).equals(endTagName)) {
				throw new XmlParseException(this, "end tag does not match start tag",
						lastIdLine, lastIdColumn);
			}
			skipSpaces();
			expect('>');
		}
		current = element.parent;
        if (traced) traceEnd("parsed node "+startTagName);
        return element;

	}
			
	/** Parses the next XML element from the input source.
	 * 
	 * @param tags the acceptable tags for the next element.
	 * @return The next XML element from the input source, or null if there is no more 
	 * element available (i.e. the end tag of the current element has been reached).
	 * @throws XmlParseException if an error occurs while parsing.
	 */
	public XmlElement<E,P> parseElement(E... tags)
	throws IOException, XmlParseException
	{
		return readElement(new SimpleChecker<E>(tags));
	}

	/** Creates a new XML element.
	 * This method can be overriden to provide parser-specific element objects. 
	 * @param tag the tag of the new element.
	 * @param parent the parent element for the new element (may be null).
	 * @return
	 */
	protected XmlElement<E,P> newElement(E tag, XmlElement<E,P> parent)
	{
		return new XmlElement<E,P>(tag, parent, this);
	}
	
	public void setElementNameMap(NameMap<E> map)
	{
		this.elementNameMap = map;
	}
		
	public void setPropertyNameMap(NameMap<P> map)
	{
		this.propertyNameMap = map;
	}
	
	/** Parses a complete XML document.
	 * 
	 * @param rootTag the expected tag for the root element.
	 * @return the document root element.
	 * @throws XmlParseException if an error occurs while parsing.
	 */
	@SuppressWarnings("unchecked")
	public XmlElement<E,P> parseDocument(E rootTag)
	throws IOException, XmlParseException
	{
		if (elementNameMap==null) elementNameMap = new DefaultMap<E>(elementNameEnum);
		if (propertyNameMap==null) propertyNameMap = new DefaultMap<P>(propertyNameEnum);
		return parseElement(rootTag);
	}
		
	protected Reader getReader()
	{
		return reader;
	}
	
    /** Parses text until the next node.
     */
    protected String readText()
    throws IOException, XmlParseException
    {
        if (traced) traceStart("parsing text...");
        textBuffer.setLength(0);
        for (;;)
        {
            char c = next();
            if (c=='<')
            {
                pushback(c);
                if (skipSpecialTags(textBuffer)>0) continue;
                if (traced) traceEnd("parsed text '"+textBuffer.toString()+"'");
                return textBuffer.toString();
            }
            else if (c=='&')
            {
                if (next('#')) 
                {
                    int codePoint = -1;
                    if (next('x')) codePoint = readInt(16);
                    else codePoint = readInt(10);
                    textBuffer.append(Character.toChars(codePoint));
                }
                else
                {
                    String name = readName();
                    if (name.equals("lt")) textBuffer.append('<');
                    else if (name.equals("gt")) textBuffer.append('>');
                    else if (name.equals("amp")) textBuffer.append('&');
                    else if (name.equals("apos")) textBuffer.append('\'');
                    else if (name.equals("quot")) textBuffer.append('"');
                    else throw new XmlParseException(this, name+": invalid entity reference");
                }
                expect(';');
            }
            else textBuffer.append(c);
        }
    }
    
    private int readInt(int base)
    throws IOException
    {
        int result = 0;
        for (;;)
        {
            char c = next();
            int value = getDigitValue(c);
            if (value==-1)
            {
                pushback(c);
                return result;
            }
            result = result * base + value;
        }
    }
    
    private int getDigitValue(char c)
    {
        switch (c)
        {
            case '0': return 0;
            case '1': return 1;
            case '2': return 2;
            case '3': return 3;
            case '4': return 4;
            case '5': return 5;
            case '6': return 6;
            case '7': return 7;
            case '8': return 8;
            case '9': return 9;
            case 'A': case 'a': return 10;
            case 'B': case 'b': return 11;
            case 'C': case 'c': return 12;
            case 'D': case 'd': return 13;
            case 'E': case 'e': return 14;
            case 'F': case 'f': return 15;
            default: return -1;
        }        
    }

    /** Skips XML document header (refered to as <i>prolog</i> in the XML specs).
     */
    public void skipDocumentHeader()
    throws IOException, XmlParseException
    {
    	readDocumentHeader();
    }
    
    private void readDocumentHeader()
    throws IOException, XmlParseException
    {
        if (traced) traceStart("parsing document header...");
        skipSpaces();
        // skip <?xml ... ?> pattern
        expect('<');
        if (!next('?')) pushback('<');
        else {
            if (!readName().equals("xml"))
            {
                throw new XmlParseException(this, "expecting 'xml' identifier");
            }
            readProperties(null);
            expect('?', '>');
        }
        // skip subsequent <! ... > patterns
        for (;;)
        {
            skipSpaces();
            if (next('<','!')) while (next()!='>');
            else break;
        }
        if (traced) traceEnd("parsed document header");
    }

    private void readProperties(XmlElement<E,P> elem)
    throws IOException, XmlParseException
    {
        for (;;)
        {
            skipSpaces();
            switch (peek())
            {
                case '>':
                case '?':
                case '/':
                    return;
            }
            String attrName = readName();
            if (traced) trace("parsing attribute "+attrName);
            P prop = null;
            if (elem!=null) {
                prop = propertyNameMap.getEnum(attrName);
        		if (prop==null) throw new XmlParseException(this, "Invalid property: "+attrName);
            }
            expect('=', '"');
            StringBuffer buffer = new StringBuffer();
            char c;
            while ((c=next())!='"') buffer.append(c);
            if (elem!=null) elem.addProperty(prop, buffer.toString());
        }
    }

    private String readName()
    throws IOException, XmlParseException
    {
        lastIdLine = line;
        lastIdColumn = column+1;
        nameBuffer.setLength(0);
        char c = next();
        if (Character.isLetter(c) || c=='_' || c==':')
        {
            nameBuffer.append(c);
        }
        else throw new XmlParseException(this, "invalid name");
        for (;;)
        {
            c = next();
            if (Character.isLetter(c))
            {
                nameBuffer.append(c);
                continue;
            }
            else switch (c)
            {
                case '.':
                case '-':
                case '_':
                case ':':
                    nameBuffer.append(c);
                    continue;
            }
            pushback(c);
            if (nameBuffer.length()==0)
            {
                throw new XmlParseException(this, "missing identifier");
            }
            return nameBuffer.toString();
        }
    }

    private void skipSpaces()
    throws IOException
    {
        for (;;)
        {
            char c = next();
            switch (c)
            {
                case '\n':
                case '\r':
                case '\t':
                case ' ':
                    continue;
            }
            pushback(c);
            return;
        }
    }

    /** Skips all &lt;!...&gt; tags, including comments, except CDATA.
     * @throws IOException
     */
    private int skipSpecialTags(StringBuffer buffer)
    throws IOException, XmlParseException
    {
        int count = 0;
        for (;;)
        {
            skipSpaces();
            if (next('<', '!'))
            {
                if (next('-', '-'))
                {
                    if (traced) trace("skipping comment");
                    for (;;) if (next()=='-' && next()=='-' && next()=='>') break;
                }
                else if (next('['))
                {
                    if (traced) trace("skipping <![X[...]]> element");
                    String name = readName();
                    expect('[');
                    if (name.equals("CDATA")) 
                    {
                        if (buffer==null) {
                        	throw new XmlParseException(this, "unexpected CDATA");
                        }
                        for (;;)
                        {
                            if (next(']',']', '>')) break;
                            buffer.append(next());
                        }
                    }
                    else for (;;) if (next()==']' && next()==']' && next()=='>') break;
                }
                else {
                    if (traced) trace("skipping <!X...> element");
                    int depth = 0;
                    for (;;) 
                    {
                        char c = next();
                        if (c=='<') depth++;
                        else if (c=='>')
                        {
                            if (depth==0) break;
                            depth--;
                        }
                    }
                }
                count++;
            }
            else break;
        }       
        return count;
    }

    private void expect(char c)
    throws IOException, XmlParseException
    {
        if (next()!=c)
        {
            throw new XmlParseException(this, "expecting character '"+c+"'");
        }
    }

    private void expect(char c1, char c2)
    throws IOException, XmlParseException
    {
        expect(c1);
        expect(c2);
    }

    private char next()
    throws IOException
    {
        int c = reader.read();
        switch (c)
        {
            case -1:
                throw new java.io.EOFException();
            case '\n':
                lastColumn = column;
                line++;
                column = 0;
                break;
            case '\r':
                break;
            default:
                column++;
        }
        return (char)c;
    }

    private char peek()
    throws IOException
    {
        int c = reader.read();
        if (c==-1) throw new java.io.EOFException();
        reader.unread(c);
        return (char)c;
    }

    private void pushback(char c)
    throws IOException
    {
        reader.unread(c);
        if (c=='\n') {
            line--;
            column = lastColumn;
        }
        else if (c!='\r') column--;
    }

    private boolean next(char c)
    throws IOException
    {
        char n = next();
        if (n==c) return true;
        pushback(n);
        return false;
    }

    private boolean next(char c1, char c2)
    throws IOException
    {
        if (!next(c1)) return false;
        if (next(c2)) return true;
        pushback(c1);
        return false;
    }

    private boolean next(char c1, char c2, char c3)
    throws IOException
    {
        if (!next(c1)) return false;
        if (next(c2)) {
            if (next(c3)) return true;
            else {
                pushback(c2);
                pushback(c1);
                return false;
            }
        }
        else {
            pushback(c1);
            return false;
        }
    }

    private void traceStart(String text)
    {
        trace(text);
        traceDepth++;
    }

    private void traceEnd(String text)
    {
        traceDepth--;
        trace(text);
    }

    private void trace(String text)
    {
        PrintStream out = traceStream; // copy to avoid sync issues
        if (out!=null)
        {
            try 
            {
                for (int i=0; i<traceDepth; i++) out.print("  ");
                out.println(text);
            }
            catch (Exception e) { }
        }
    }
    
    /** Enables/disables traces.
     * @param stream the stream to which messages will be written.
     */
    public void traceTo(PrintStream stream)
    {
        if (stream!=null) traced = true;
        else traced = false;
        this.traceStream = stream;
    }

    /** Aborts parsing.
     * This method always throws an XmlParseException.
     */
    protected void abort(String msg)
    throws XmlParseException
    {
        throw new XmlParseException(this, msg);
    }

    /** Aborts parsing.
     * This method always throws an XmlParseException.
     */
    protected void abort()
    throws XmlParseException
    {
        throw new XmlParseException(this, "Parsing failed");
    }

    /** Aborts parsing.
     * This method always throws an XmlParseException.
     */
    protected void abortMissingProperty(P attr)
    throws XmlParseException
    {
        throw new XmlParseException(this, "Property missing: "+propertyNameMap.getName(attr));
    }

}
